// Copyright (c) 2010-2013 AlphaSierraPapa for the SharpDevelop Team
// 
// Permission is hereby granted, free of charge, to any person obtaining a copy of this
// software and associated documentation files (the "Software"), to deal in the Software
// without restriction, including without limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
// to whom the Software is furnished to do so, subject to the following conditions:
// 
// The above copyright notice and this permission notice shall be included in all copies or
// substantial portions of the Software.
// 
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Runtime.Serialization;
using System.Text;
using System.Xml;

namespace dnSpy.Contracts.Decompiler.XmlDoc {
	/// <summary>
	/// Provides documentation from an .xml file (as generated by the Microsoft C# compiler).
	/// </summary>
	/// <remarks>
	/// This class first creates an in-memory index of the .xml file, and then uses that to read only the requested members.
	/// This way, we avoid keeping all the documentation in memory.
	/// The .xml file is only opened when necessary, the file handle is not kept open all the time.
	/// If the .xml file is changed, the index will automatically be recreated.
	/// </remarks>
	[Serializable]
	public class XmlDocumentationProvider : IDeserializationCallback
	{
		#region Cache
		sealed class XmlDocumentationCache
		{
			readonly KeyValuePair<string, string?>[] entries;
			int pos;
			
			public XmlDocumentationCache(int size = 50)
			{
				if (size <= 0)
					throw new ArgumentOutOfRangeException(nameof(size), size, "Value must be positive");
				entries = new KeyValuePair<string, string?>[size];
			}
			
			internal bool TryGet(string key, out string? value)
			{
				foreach (var pair in entries) {
					if (pair.Key == key) {
						value = pair.Value;
						return true;
					}
				}
				value = null;
				return false;
			}
			
			internal void Add(string key, string? value)
			{
				entries[pos++] = new KeyValuePair<string, string?>(key, value);
				if (pos == entries.Length)
					pos = 0;
			}
		}
		#endregion
		
		[Serializable]
		struct IndexEntry : IComparable<IndexEntry>
		{
			/// <summary>
			/// Hash code of the documentation tag
			/// </summary>
			internal readonly int HashCode;
			
			/// <summary>
			/// Position in the .xml file where the documentation starts
			/// </summary>
			internal readonly int PositionInFile;
			
			internal IndexEntry(int hashCode, int positionInFile)
			{
				HashCode = hashCode;
				PositionInFile = positionInFile;
			}

			public int CompareTo(IndexEntry other) => HashCode.CompareTo(other.HashCode);
		}
		
		[NonSerialized]
		XmlDocumentationCache cache = new XmlDocumentationCache();
		
		readonly string fileName;
		readonly Encoding encoding;
		volatile IndexEntry[] index; // SORTED array of index entries

		/// <summary>
		/// Creates a new XmlDocumentationProvider. Can return null if we couldn't read the file.
		/// </summary>
		/// <param name="fileName">Name of the .xml file.</param>
		/// <returns>null if we couldn't create it</returns>
		public static XmlDocumentationProvider? Create(string fileName)
		{
			if (fileName is null)
				return null;
			try {
				return new XmlDocumentationProvider(fileName);
			} catch {
			}
			return null;
		}

		#region Constructor / Redirection support
		Encoding GetEncoding(Encoding encoding) {
			var clone = (Encoding)encoding.Clone();
			Debug.Assert(!clone.IsReadOnly);
			if (clone.IsReadOnly)
				return encoding;
			clone.EncoderFallback = new EncoderReplacementFallback("\uFFFD");
			clone.DecoderFallback = new DecoderReplacementFallback("\uFFFD");
			return clone;
		}

		/// <summary>
		/// Creates a new XmlDocumentationProvider.
		/// </summary>
		/// <param name="fileName">Name of the .xml file.</param>
		/// <exception cref="IOException">Error reading from XML file (or from redirected file)</exception>
		/// <exception cref="XmlException">Invalid XML file</exception>
		public XmlDocumentationProvider(string fileName)
		{
			if (fileName is null)
				throw new ArgumentNullException(nameof(fileName));

			index = new IndexEntry[0];
			using (FileStream fs = new FileStream(fileName, FileMode.Open, FileAccess.Read, FileShare.Read | FileShare.Delete)) {
				using (XmlTextReader xmlReader = new XmlTextReader(fs)) {
					xmlReader.XmlResolver = null; // no DTD resolving
					xmlReader.MoveToContent();
					var redirectAttr = (string?)xmlReader.GetAttribute("redirect");
					if (string2.IsNullOrEmpty(redirectAttr)) {
						this.fileName = fileName;
						encoding = GetEncoding(xmlReader.Encoding ?? throw new InvalidOperationException());
						ReadXmlDoc(xmlReader);
					} else {
						var redirectionTarget = GetRedirectionTarget(fileName, redirectAttr);
						if (redirectionTarget is not null) {
							//Debug.WriteLine("XmlDoc " + fileName + " is redirecting to " + redirectionTarget);
							using (FileStream redirectedFs = new FileStream(redirectionTarget, FileMode.Open, FileAccess.Read, FileShare.Read | FileShare.Delete)) {
								using (XmlTextReader redirectedXmlReader = new XmlTextReader(redirectedFs)) {
									redirectedXmlReader.XmlResolver = null; // no DTD resolving
									redirectedXmlReader.MoveToContent();
									this.fileName = redirectionTarget;
									encoding = GetEncoding(redirectedXmlReader.Encoding ?? throw new InvalidOperationException());
									ReadXmlDoc(redirectedXmlReader);
								}
							}
						} else {
							throw new XmlException("XmlDoc " + fileName + " is redirecting to " + redirectAttr + ", but that file was not found.");
						}
					}
				}
			}
		}
		
		static string? GetRedirectionTarget(string xmlFileName, string target)
		{
			var programFilesDir = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86);
			if (string.IsNullOrEmpty(programFilesDir))
				programFilesDir = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
			programFilesDir = AppendDirectorySeparator(programFilesDir);
			
			string corSysDir = System.Runtime.InteropServices.RuntimeEnvironment.GetRuntimeDirectory();
			corSysDir = AppendDirectorySeparator(corSysDir);
			
			var fileName = target.Replace ("%PROGRAMFILESDIR%", programFilesDir)
				.Replace ("%CORSYSDIR%", corSysDir);
			if (!Path.IsPathRooted (fileName))
				fileName = Path.Combine (Path.GetDirectoryName (xmlFileName)!, fileName);
			return LookupLocalizedXmlDoc(fileName);
		}
		
		static string AppendDirectorySeparator(string dir)
		{
			if (dir.EndsWith("\\", StringComparison.Ordinal) || dir.EndsWith("/", StringComparison.Ordinal))
				return dir;
			else
				return dir + Path.DirectorySeparatorChar;
		}
		
		/// <summary>
		/// Given the assembly file name, looks up the XML documentation file name.
		/// Returns null if no XML documentation file is found.
		/// </summary>
		public static string? LookupLocalizedXmlDoc(string fileName)
		{
			string xmlFileName = Path.ChangeExtension(fileName, ".xml");
			string currentCulture = System.Threading.Thread.CurrentThread.CurrentUICulture.TwoLetterISOLanguageName;
			string localizedXmlDocFile = GetLocalizedName(xmlFileName, currentCulture);
			
			//Debug.WriteLine("Try find XMLDoc @" + localizedXmlDocFile);
			if (File.Exists(localizedXmlDocFile)) {
				return localizedXmlDocFile;
			}
			//Debug.WriteLine("Try find XMLDoc @" + xmlFileName);
			if (File.Exists(xmlFileName)) {
				return xmlFileName;
			}
			if (currentCulture != "en") {
				string englishXmlDocFile = GetLocalizedName(xmlFileName, "en");
				//Debug.WriteLine("Try find XMLDoc @" + englishXmlDocFile);
				if (File.Exists(englishXmlDocFile)) {
					return englishXmlDocFile;
				}
			}
			return null;
		}
		
		static string GetLocalizedName(string fileName, string language)
		{
			string localizedXmlDocFile = Path.GetDirectoryName(fileName)!;
			localizedXmlDocFile = Path.Combine(localizedXmlDocFile, language);
			localizedXmlDocFile = Path.Combine(localizedXmlDocFile, Path.GetFileName(fileName));
			return localizedXmlDocFile;
		}
		#endregion
		
		#region Load / Create Index
		void ReadXmlDoc(XmlTextReader reader)
		{
			//lastWriteDate = File.GetLastWriteTimeUtc(fileName);
			// Open up a second file stream for the line<->position mapping
			using (FileStream fs = new FileStream(fileName, FileMode.Open, FileAccess.Read, FileShare.Read | FileShare.Delete)) {
				LinePositionMapper linePosMapper = new LinePositionMapper(fs, encoding);
				List<IndexEntry> indexList = new List<IndexEntry>();
				while (reader.Read()) {
					if (reader.IsStartElement()) {
						switch (reader.LocalName) {
							case "members":
								ReadMembersSection(reader, linePosMapper, indexList);
								break;
						}
					}
				}
				indexList.Sort();
				index = indexList.ToArray(); // volatile write
			}
		}
		
		sealed class LinePositionMapper
		{
			readonly FileStream fs;
			readonly Decoder decoder;
			int currentLine = 1;
			
			// buffers for use with Decoder:
			readonly byte[] input = new byte[1];
			readonly char[] output = new char[2];
			
			public LinePositionMapper(FileStream fs, Encoding encoding)
			{
				decoder = encoding.GetDecoder();
				this.fs = fs;
			}
			
			public int GetPositionForLine(int line)
			{
				Debug.Assert(line >= currentLine);
				var inputLocal = input;
				var outputLocal = output;
				while (line > currentLine) {
					int b = fs.ReadByte();
					if (b < 0)
						throw new EndOfStreamException();
					inputLocal[0] = (byte)b;
					decoder.Convert(inputLocal, 0, 1, outputLocal, 0, outputLocal.Length, false, out int bytesUsed, out int charsUsed, out bool completed);
					Debug.Assert(bytesUsed == 1);
					for (int i = 0; i < charsUsed; i++) {
						if (outputLocal[i] == '\n')
							currentLine++;
					}
				}
				return checked((int)fs.Position);
			}
		}
		
		static void ReadMembersSection(XmlTextReader reader, LinePositionMapper linePosMapper, List<IndexEntry> indexList)
		{
			while (reader.Read()) {
				switch (reader.NodeType) {
					case XmlNodeType.EndElement:
						if (reader.LocalName == "members") {
							return;
						}
						break;
					case XmlNodeType.Element:
						if (reader.LocalName == "member") {
							int pos = linePosMapper.GetPositionForLine(reader.LineNumber) + Math.Max(reader.LinePosition - 2, 0);
							var memberAttr = (string?)reader.GetAttribute("name");
							if (memberAttr is not null)
								indexList.Add(new IndexEntry(GetHashCode(memberAttr), pos));
							reader.Skip();
						}
						break;
				}
			}
		}
		
		/// <summary>
		/// Hash algorithm used for the index.
		/// This is a custom implementation so that old index files work correctly
		/// even when the .NET string.GetHashCode implementation changes
		/// (e.g. due to .NET 4.5 hash randomization)
		/// </summary>
		static int GetHashCode(string key)
		{
			unchecked {
				int h = 0;
				foreach (char c in key) {
					h = (h << 5) - h + c;
				}
				return h;
			}
		}
		#endregion
		
		#region GetDocumentation
		/// <summary>
		/// Get the documentation for the member with the specified documentation key.
		/// </summary>
		public string? GetDocumentation(string key)
		{
			if (key is null)
				return null;
			return GetDocumentation(key, true);
		}
		
		/// <summary>
		/// Get the documentation for the member with the specified documentation key.
		/// </summary>
		public string? GetDocumentation(StringBuilder? key)
		{
			if (key is null)
				return null;
			//TODO: Try to prevent ToString()
			return GetDocumentation(key.ToString(), true);
		}
		
		string? GetDocumentation(string key, bool allowReload)
		{
			int hashcode = GetHashCode(key);
			var index = this.index; // read volatile field
			// index is sorted, so we can use binary search
			int m = Array.BinarySearch(index, new IndexEntry(hashcode, 0));
			if (m < 0)
				return null;
			// correct hash code found.
			// possibly there are multiple items with the same hash, so go to the first.
			while (--m >= 0 && index[m].HashCode == hashcode);
			// m is now 1 before the first item with the correct hash
			
			XmlDocumentationCache cache = this.cache;
			lock (cache) {
				if (!cache.TryGet(key, out string? val)) {
					try {
						// go through all items that have the correct hash
						while (++m < index.Length && index[m].HashCode == hashcode) {
							val = LoadDocumentation(key, index[m].PositionInFile);
							if (val is not null)
								break;
						}
						// cache the result (even if it is null)
						cache.Add(key, val);
					} catch (IOException) {
						// may happen if the documentation file was deleted/is inaccessible/changed (EndOfStreamException)
						return allowReload ? ReloadAndGetDocumentation(key) : null;
					} catch (XmlException) {
						// may happen if the documentation file was changed so that the file position no longer starts on a valid XML element
						return allowReload ? ReloadAndGetDocumentation(key) : null;
					}
				}
				return val;
			}
		}
		
		string? ReloadAndGetDocumentation(string key)
		{
			try {
				// Reload the index
				using (FileStream fs = new FileStream(fileName, FileMode.Open, FileAccess.Read, FileShare.Read | FileShare.Delete)) {
					using (XmlTextReader xmlReader = new XmlTextReader(fs)) {
						xmlReader.XmlResolver = null; // no DTD resolving
						xmlReader.MoveToContent();
						ReadXmlDoc(xmlReader);
					}
				}
			} catch (IOException) {
				// Ignore errors on reload; IEntity.Documentation callers aren't prepared to handle exceptions
				index = new IndexEntry[0]; // clear index to avoid future load attempts
				return null;
			} catch (XmlException) {
				index = new IndexEntry[0]; // clear index to avoid future load attempts
				return null;				
			}
			return GetDocumentation(key, allowReload: false); // prevent infinite reload loops
		}
		#endregion
		
		#region Load / Read XML
		string? LoadDocumentation(string key, int positionInFile)
		{
			using (FileStream fs = new FileStream(fileName, FileMode.Open, FileAccess.Read, FileShare.Read | FileShare.Delete)) {
				fs.Position = positionInFile;
				var context = new XmlParserContext(null, null, null, XmlSpace.None) { Encoding = encoding };
				using (XmlTextReader r = new XmlTextReader(fs, XmlNodeType.Element, context)) {
					r.XmlResolver = null; // no DTD resolving
					while (r.Read()) {
						if (r.NodeType == XmlNodeType.Element) {
							var memberAttr = (string?)r.GetAttribute("name");
							if (memberAttr == key) {
								return r.ReadInnerXml();
							} else {
								return null;
							}
						}
					}
					return null;
				}
			}
		}
		#endregion

		/// <inheritdoc/>
		public virtual void OnDeserialization(object? sender) => cache = new XmlDocumentationCache();
	}
}
